Многопоточное программирование в Java - Тимур Машнин
Шрифт:
Интервал:
Закладка:
Синхронизация необходима для взаимоисключающего доступа к блокам и для надежной связи между потоками.
Синхронизация метода обеспечивает, что, когда один поток выполняет синхронизированный метод объекта, все другие потоки, которые вызывают синхронизированные методы этого объекта приостанавливают выполнение до тех пор, пока первый поток не закончит свою работу с объектом.
Когда синхронизированный метод завершится, он автоматически установит причинно-следственную связь для последующего вызова синхронизированного метода этого объекта.
Это гарантирует, что изменения состояния объекта будут видны для всех потоков.
Когда поток вызывает синхронизированный метод, он автоматически получает внутреннюю блокировку для объекта этого метода и освобождает его при возврате метода.
Освобождение блокировки происходит, даже если возврат метода был вызван неперехваченным исключением.
Другими словами, каждый объект в Java имеет ассоциированный с ним монитор.
Монитор представляет своего рода инструмент для управления доступа к объекту.
Когда выполнение кода доходит до оператора synchronized, монитор объекта захватывается владельцем, и на это время монопольный доступ к синхронизированному коду имеет только один поток, который является владельцем монитора.
После окончания работы блока кода, монитор объекта освобождается и становится доступным для других потоков.
Когда вызывается статический синхронизированный метод, так как статический метод связан с классом, а не с объектом, в этом случае поток получает блокировку для объекта Class, связанного с классом и представляющего класс в среде выполнения.
Таким образом, доступ к синхронизированным статическим полям класса контролируется блокировкой, отличной от блокировки для любого экземпляра класса. Поэтому статические синхронизированные методы и нестатические синхронизированные методы никогда не заблокируют друг друга.
Конструкторы не могут быть синхронизированы — использование ключевого слова synchronized для конструктора является синтаксической ошибкой.
Синхронизация конструктора не имеет смысла, потому что только поток, который создает объект, имеет доступ к нему во время его создания.
Еще раз, если два нестатических метода класса объявлены как synchronized, то в каждый момент времени из разных потоков на одном объекте может быть вызван только один из них.
Поток, который вызывает метод первым, захватит монитор, и второму потоку придется ждать.
Это верно только для разных потоков.
Один и тот же поток может вызвать синхронизированный метод, внутри него — другой синхронизированный метод на том же экземпляре. И это будет повторная блокировка.
Поскольку этот поток владеет монитором, проблем второй вызов не создаст.
Это верно только для вызовов методов одного экземпляра.
У разных экземпляров разные мониторы, поэтому одновременный вызов нестатических методов проблем не создаст.
Другой способ создания синхронизированного кода — синхронизированные блоки.
В отличие от синхронизированных методов, синхронизированные блоки должны указывать объект, который обеспечивает внутреннюю блокировку.
Когда один поток заходит внутрь блока кода, помеченного словом synchronized, то Java-машина тут же блокирует монитор объекта, который указан в круглых скобках после слова synchronized.
Больше ни один поток не сможет зайти в этот блок, пока наш поток его не покинет.
Как только наш поток выйдет из блока, помеченного synchronized, то монитор тут же автоматически освобождается и будет свободен для захвата другим потоком.
Для нестатических методов, синхронизация метода эквивалентна синхронизации тела метода с объектом this.
Для статических методов, синхронизация метода эквивалентна синхронизации тела метода с объектом Class.
Предположим, что класс имеет два поля экземпляра: c1 и c2, которые никогда не используются вместе.
Все обновления этих полей должны быть синхронизированы, но нет никаких причин препятствовать тому, чтобы обновление c1 чередовалось с обновлением c2, чтобы не создавать ненужную блокировку.
Вместо использования синхронизированных методов или использования блокировки this, мы создаем два объекта исключительно для обеспечения блокировок.
Таким образом синхронизация блоков может дать возможность из разных потоков на одном объекте вызывать разные синхронизированные блоки.
Атомарный доступ и volatile
В программировании атомарное действие — это действие, которое происходит за один раз.
Атомарное действие не может остановиться посередине: оно либо происходит полностью, либо вообще не происходит.
Никакие промежуточные результаты атомарного действия не видны, пока действие не будет завершено.
Например, оператор инкремента ++, не является атомарным действием.
Он состоит из следующих действий:
— Получить текущее значение.
— Увеличить полученное значение на 1.
— Сохранить увеличенное значение.
Поэтому очень простые выражения могут определять сложные действия, которые могут разлагаться на другие действия.
Но есть действия, которые являются атомарными:
Это чтение и запись всех переменных, ссылочных на объекты и примитивных переменных, за исключением переменных типа long и double.
Так как в Java 64-битные long и double значения рассматриваются как два 32-битных значения.
Это означает, что 64-разрядная операция записи выполняется как две отдельные 32-разрядные операции.
И это значит, что действия с long и double переменными не являются потокобезопасными.
Когда несколько потоков получают доступ к long или double значению без синхронизации, это может вызвать проблемы.
Чтобы обеспечить атомарность действий с long и double значениями можно использовать ключевое слово volatile.
Если переменная объявлена как volatile, это означает, что она может изменяться разными потоками.
Среда выполнения JRE неявно обеспечивает синхронизацию при доступе к volatile-переменным, но с очень большой оговоркой: чтение volatile-переменной и запись в volatile-переменную синхронизированы, а неатомарные операции, такие как операция инкремента или декремента ― нет.
Атомарные действия не могут перемешиваться, поэтому их можно использовать, не опасаясь интерференции потоков.
Однако это не устраняет необходимости синхронизации атомарных действий, так как возможны ошибки согласованности памяти.
В целях повышения производительности среда выполнения JRE сохраняет локальные копии переменных для каждого потока, который на них ссылается.
Такие «локальные» копии переменных работают как кэш и помогают потоку избежать обращения к главной памяти каждый раз, когда требуется получить значение переменной.
Поэтому если один поток изменит значение переменной, то другой поток может не увидеть это изменение, так как будет считывать значение из своего кэша. Это и будет ошибкой согласованности памяти
Однако если переменная помечена как volatile, то, когда бы поток не считывал значение, он будет считывать ее текущее значение.
Использование volatile переменных, не только для long и double, снижает риск ошибок согласованности памяти, поскольку любая запись в volatile переменную устанавливает связь между событиями и последующими чтениями этой же переменной.
Это означает, что изменения в volatile переменной всегда видны для других потоков.
Опять же речь идет только об операциях чтения